Pointer 기본 개념 잡기
포인터는 메모리 주소를 저장하는 특별한 변수로, 메모리 주소는 컴퓨터의 RAM(Random Access Memory)에서 특정 데이터가 저장된 고유한 위치를 나타내는 숫자값을 의미한다. 개발자가 어떤 변수를 선언하여 값을 할당하면 변수 데이터는 메모리의 특정 위치에 할당된 값을 저장된다. 즉 해당 변수는 특정 메모리 위치에 할당받은 값을 저장한다. 이 메모리의 위치(메모리 주소)를 나타내는 것이 바로 포인터다. 코드로 이해해보자.
go/* - 포인터 선언 - var 변수명 *타입 <<< asterik 을 붙여 포인터 선언 - int 타입의 포인터 */ var po *int
po 변수는 포인터로 선언되었다. 메모리의 특정 위치에 int 타입의 값이 저장되면 po로 어떤 메모리 주소를 참조하는지 알 수 있다.
아래 예시에서 number 라는 Int type 변수가 나온다. 이 변수가 어떤 값, 예를 들어 100을 저장하면 메모리의 어떤 주소에 100이란 값이 저장된다. 개발자들은 100이 어떤 메모리 주소에 저장되는지 신경쓰지 않아도 되지만, 컴파일러 입장에서는 이 메모리 주소를 알아야 한다. 이 때 po 포인터 변수를 통해 number 변수가 어느 메모리 주소를 참조하는지 알아낼 수 있는 것이다.
govar number int var po *int po = &number
number 변수 앞에 붙은 ampersand(&) 는 무엇을 의미하는가?
& 기호는 메모리의 주소를 명시하라는 operator 로 number 변수가 어떤 메모리 주소에 값을 저장하는지 알 수 있다. 즉 &number 는 메모리 주소를 의미하기에 po 포인터 변수에 할당할 수 있는 것이다.
gofunc main() { var number int = 10 var pointer *int pointer = &number fmt.Println("number:", number) fmt.Println("pointer:", pointer) fmt.Println("*pointer:", *pointer) fmt.Println("&number:", &number) } > number: 10 > pointer: 0xc000104040 > *pointer: 10 > &number: 0xc000104040
number 변수의 10 이 저장된 위치가 0xc000104040 이므로, number 의 메모리 주소를 할당받은 pointer 역시 동일한 메모리 주소값을 출력한다.
역참조
위 예제에서 pointer 변수에 asterik(*)를 붙이면 10이 출력되는 것을 확인했다.
pointer 변수는 number 의 메모리 주소를 참조하는 포인터인데 이 포인터에 asterik(*) 을 붙이면 number 값에 접근할 수 있다.
예제의 세번째 출력문을 보면 포인터 변수에 asterik 을 붙여 값(10)에 접근할 수 있다.
값에 접근할 수 있다는 건 수정도 가능하다는 의미로 아래 예제를 통해 확인해보자.
gofunc main() { var number int var pointer *int number = 10 pointer = &number fmt.Println("[before] number:", number) // 역참조로 값 수정 시도 *pointer = 0 fmt.Println("*pointer:", *pointer) fmt.Println("[after] number:", number) } > [before] number: 10 > *pointer: 0 > [after] number: 0
분명 최초 number 변수는 10 을 할당받지만 마지막 출력문에서는 0 을 가지고 있다. *pointer 를 통해 number 변수의 메모리 주소 값(value of memory address) 에 접근하여 다른 값(0)을 할당할 수 있다.
Pointer 를 사용하는 이유
이제 구조체에서 pointer 를 사용해보자.
두 개 메서드 (editName, editNamePointer) 의 메서드 로직은 같지만 메서드 시그니처에 차이가 있다. 전자는 Item 구조체의 값을 받고 후자는 Item 구조체의 포인터를 전달받는다.
gotype Item struct { name string } func editName(item Item) { item.name = "커피" fmt.Println("editName:", item.name) fmt.Println(&item.name) } func editNamePointer(item *Item) { item.name = "커피" fmt.Println("editName:", item.name) fmt.Println(&item.name) } func main() { item := Item {"음료수"} fmt.Println("출력:", item.name) fmt.Println("출력:", &item.name) editName(item) fmt.Println("결과:", item.name) // &item 은 item 구조체의 메모리 주소 -> 인자로 메모리 주소를 넘긴다. editNamePointer(&item) // item 을 넣으면 compile Error 발생 fmt.Println("결과:", item.name) } > 출력: 음료수 > 출력: 0xc00009a070 > editName: 커피 > 0xc00009a090 > 결과: 음료수 // name 이 커피로 변경되지 않음 (원본 영향 받지 않음) > editName: 커피 > 0xc00009a070 > 결과: 커피 // name 이 커피로 변경됨 (원본 수정됨) // 각 함수 내부에서 메모리 주소를 출력하면 서로 다른 메모리 주소가 넘어왔음을 확인할 수 있다.
Go 는 기본적으로 Call by value 를 따르기에 매개변수를 복사해서 함수 내부로 전달한다. 원본 객체가 아닌 복사본을 만들어 메서드의 매개변수로 넘기는 셈이다. 만약 원본을 수정하고 싶다면 포인터를 통해 수정할 수 있다.
editNamePointer 는 Item 객체의 메모리 주소를 포인터 변수 형태로 전달받고 내부에서는 전달받은 인자의 메모리 주소로 접근 가능하여 원본의 값을 수정할 수 있다.
반면 editName는 인자로 일반 객체를 전달받는데, 여기서 일반 객체란 원본의 **복사본*8을 의미한다.
즉 복사본의 값을 수정하더라도 원본은 영향을 받지 않기 때문에 editName() 함수를 escape 후에 item 객체의 값을 확인하면 변하지 않은 걸 확인할 수 있다.
Q. editNamePointer 는 인자로 포인터 변수를 받는다. 인자는 asterik(*)인데 함수 호출부에서 넘기는 것은 ampersand(&) 다. 그럼 메서드 정의 할 때 & 타입으로 인자를 정의하면 왜 컴파일 에러가 발생할까?
& 는 연산자로써 “이 값의 메모리 주소를 가져온다” 를 의미한다. 즉 어떤 값의 메모리 주소를 가져올 때 사용하는 연산자이기에 타입을 선언시 사용하는 것은 적절하지 않다.
정리하면
- *Item 은 “Item 변수를 가리키는 포인터 타입”
- &Item 은 “Item 변수의 메모리 주소”
Go 는 왜 굳이 포인터를 도입했을까?
Go 는 안정성과 간결함이라는 언어 철학 아래 call by value 로 설계되어 개발자가 코드의 동작을 명확히 이해하고, 메모리 관리와 관련된 버그를 줄이며 직관적인 프로그래밍 경험 제공을 목표로 둔다. Go 가 call by reference 로 동작했다면 데이터가 어디서 어떻게 변경되는지 추적하기 어려워 코드의 복잡성을 증가시킨다. Go의 call by value는 이런 복잡성을 줄이고 코드의 예측 가능성을 높이는 효과를 가진다.
이런 언어 철학을 기조로 Pointer 를 생각해보자.
gotype HugeStruct struct { data [1000000]int // 매우 큰 데이터를 가진 구조체 } // 포인터 사용하지 않을 경우 func processData(h HugeStruct) { // 1,000,000개의 정수가 모두 복사됨 } // 포인터 사용할 경우 func processDataPtr(h *HugeStruct) { // 메모리 주소만 복사 (8바이트 정도) }
포인터는 값 자체를 복사하지 않고 메모리 주소를 전달하여 메모리 사용량을 줄이고 대규모 데이터를 다룰 때 불필요한 복사를 방지할 수 있다.
예시처럼 HugeStruct 같은 대규모 데이터를 값으로 전달하면 함수를 호출할 때마다 HugeStruct 전체가 메모리에 복사된다.
위 과정이 반복되면 최악의 경우 OOM(Out Of Memory)이 발생할 가능성도 있다.
반면 포인터를 사용한다면?
대규모 데이터를 직접 복사하지 않고 메모리 주소만 전달하므로 복사에 따른 오버헤드가 줄어들고 메모리 사용량도 크게 절감할 수 있다.
함수 호출 시 값 자체가 아닌 주소를 전달하기 때문에 동일한 데이터의 여러 복사본을 생성하지 않아도 된다. *HugeStruct 같은 포인터를 함수로 전달하면 함수 내부에서 데이터에 접근하거나 수정할 때도 메모리 주소를 통해 원본 데이터에 직접 접근할 수 있다. 함수 내부에서 데이터 수정이 필요한 경우엔 복사본이 아닌 원본 데이터를 직접 수정할 수 있어 불필요한 데이터 복사도 줄여 성능을 높일 수 있다.
아래 코드는 pointer 를 사용하면 코드의 의도를 파악할 수 있다는 점을 보여준다.
gotype User struct { name string age int } // 포인터를 사용하면 함수가 원본 데이터를 변경할 수 있다는 것을 // 타입 시그니처만으로도 명확하게 알 수 있음 func updateAge(u *User) { u.age += 1 } // 포인터를 사용하지 않으면 값이 변경되지 않음을 암시 func displayInfo(u User) { fmt.Println(u.name, u.age) }
메서드 시그니처에 포인터를 정의하면 해당 함수 내부에서 인자로 전달받은 값을 수정하겠다는 의도를 드러낸다. 반면 포인터가 아닌 값 객체를 인자로 정의하면, 해당 함수 내부에서는 값을 수정하지 않고 사용(read)만 하겠다는 의도를 나타낼 수 있다. 포인터가 다소 어렵게 느껴질 수 있지만 데이터 상태 변경의 명확성을 드러내기에 개발 시 코드의 의도를 명확히 파악할 수 있다.
번외) Java 의 포인터는?
Java 는 포인터 개념이 없는데 언어 자체가 이미 call by reference 로 설계 되어있기 때문이다. 자바의 경우 객체를 다룰 때 항상 참조(reference)를 통해 처리하는데, Go의 포인터와 달리 명시적이지 않고 언어 차원에서 자동으로 처리된다.
javaclass HugeObject { int[] data = new int[1000000]; void processData() { // data 처리 } } void someMethod(HugeObject obj) { // 참조로 전달됨 obj.processData(); // 원본 객체의 메서드 호출 } // 사용 HugeObject huge = new HugeObject(); someMethod(huge); // 참조만 전달됨 (실제 데이터는 복사되지 않음)
go 와 달리 포인터를 명시하지 않더라도 언어 차원에서 자동으로 참조를 넘기기 때문에 별도의 메모리 복사가 발생되지 않는다. 즉 원본 데이터를 넘기는 것(처럼 보임)으로 메서드 내부에서 데이터를 직접 읽고 수정까지 할 수 있다. HugeObject 처럼 큰 객체를 전달할 때도 당연히 메모리 복사 문제가 발생하지 않는다.
마무리
Go가 포인터를 도입한 이유는 크게 세 가지로 정리할 수 있다:
- 메모리 효율성:
- 큰 구조체나 데이터를 함수에 전달할 때 전체를 복사하지 않고 메모리 주소만 전달
- 불필요한 메모리 사용을 줄이고 성능 향상 도모
- OOM(Out Of Memory) 같은 잠재적 문제 예방
- 코드 의도의 명확성:
- 포인터 사용 여부를 통해 함수가 데이터를 수정할 것인지 단순 참조할 것인지 명시 가능
- 메서드 시그니처만으로도 데이터 변경 여부를 예측 가능
- 코드의 가독성과 유지보수성 향상
- 개발자에게 더 많은 제어권 제공:
- Call by value를 기본으로 하되, 필요한 경우 포인터를 통한 참조 전달 가능
- Java와 달리 값 복사와 참조 전달을 개발자가 명시적으로 선택 가능
- 상황에 따른 최적의 방법 선택 가능
이처럼 Go의 포인터는 단순히 메모리 주소를 다루는 도구를 넘어, 성능 최적화와 코드 의도의 명확한 표현이라는 두 가지 핵심 가치를 제공한다. Go가 추구하는 명시적인 것이 암시적인 것보다 낫다는 철학이 포인터를 통해 잘 구현되어 있음을 알 수 있다.